Skip to content

Backbone-only side-chain completion: scorable PoseStack from N/CA/C/O (follow-up to #465) - #380

Open
ssiddhantsharma wants to merge 1 commit into
uw-ipd:masterfrom
ssiddhantsharma:ssiddhantsharma/pose_stack_from_backbone_coords
Open

Backbone-only side-chain completion: scorable PoseStack from N/CA/C/O (follow-up to #465)#380
ssiddhantsharma wants to merge 1 commit into
uw-ipd:masterfrom
ssiddhantsharma:ssiddhantsharma/pose_stack_from_backbone_coords

Conversation

@ssiddhantsharma

@ssiddhantsharma ssiddhantsharma commented May 23, 2026

Copy link
Copy Markdown

The gap

Every structure-prediction adapter on master requires the side-chain heavy
atoms to already be there. pose_stack_from_canonical_form builds missing
leaf atoms but rejects blocks missing non-leaf atoms
(fail_on_missing_nonleaf_atoms=not return_block_has_missing_atoms), so
pose_stack_from_openfold, pose_stack_from_atomworks and
pose_stack_from_atom37_and_biotite all reject backbone-only input. Only
pose_stack_from_biotite completes side chains, and it needs a caller-supplied
AtomArray.

Backbone generators — RFdiffusion, mosaic, partial-diffusion and hallucination
loops — emit exactly N/CA/C/O and an integer sequence, and nothing else. Today
the only way to score one is to write a PDB and read it back.

This PR adds that one missing step: bare (L, 4, 3) backbone plus residue-type
indices in, scorable PoseStack out.

API

ps = tmol.pose_stack_from_backbone_coords(
    coords,      # (L, 4, 3) or (n_poses, L, 4, 3) — N, CA, C, O
    res_types,   # indices into "ARNDCQEGHILKMFPSTWYV"; -1 is padding
    chain_id,
    device,
    sidechain_completion="pack",   # or "none"
)

sidechain_completion is a required decision, spelled out rather than implied:

  • "pack" (default) runs build_missing_sidechains — a full
    score-function-driven Dunbrack + OptH packing job. The docstring says so at
    the call site: expensive, dependent on beta2016 and the Dunbrack library, and
    the rebuilt atoms are not a differentiable function of the input.
  • "none" does the coordinate conversion only. Absent side chains stay
    NaN, the result is not scorable, and the conversion is cheap and fully
    differentiable — for callers that place side chains themselves or want to
    batch the packing.

Under both modes the supplied backbone is returned bit-for-bit and stays on
the autograd tape: after packing, every finite input atom is routed back to the
canonical tensor through the same _restore_canonical_input_coords mechanism
pose_stack_from_atom37_and_biotite uses.

Two things the new tests turned up

Backbone-only input leaves a NaN HA on every non-GLY residue. HA's
internal-coordinate frame is CA / N / CB, so build_missing_leaf_atoms — which
runs before packing — cannot place it when CB does not exist yet, and the
packer only rebuilds heavy atoms and never revisits it. On ubiquitin that was 70
NaN atoms in the 70 blocks flagged as incomplete. "pack" therefore rebuilds the
pose once after packing, when the heavy atoms exist; that second pass costs
~0.00 s against ~0.70 s for the packing itself and deliberately does not pass
return_block_has_missing_atoms, so anything still absent raises rather than
silently returning NaN. This is not specific to this adapter — any backbone-only
input reaching pose_stack_from_canonical_form hits it.

Side-chain placement is not reproducible from a torch seed on CPU. The CPU
simulated annealer draws from libc rand()
(tmol/pack/compiled/compiled.cpu.cpp, lines 29 / 109 / 220), which
torch.manual_seed does not control; repeated packs of the same input differ by
up to ~10 Å. test_default_pose_builds_reuse_packing_setup passes only because
a complete 1UBQ needs no Dunbrack packing and takes the OptH-only path.

That reaches topology too, not just coordinates: the HIS tautomer is resolved
from the packed side chain, so ubiquitin's His68 comes back as HIS on some
builds and HIS_D on others. The test here therefore asserts what is actually
guaranteed — setup reuse, atom layout, and a bit-identical backbone — and says
why in the docstring, rather than asserting a reproducibility the library does
not currently provide. Happy to tighten it if you would rather seed the
annealer.

Reuse — no new format-specific caches

The chemistry is shared verbatim with the atomworks adapter, whose protein
subset is already exactly what a backbone needs:

  • _paramdb_for_atomworks / _restype_set_for_atomworks /
    canonical_ordering_for_atomworks / packed_block_types_for_atomworks
  • _get_aw_2_tmol_mappings for the atom and residue-type tables

Two facts make that reuse exact, and both are asserted in the tests rather than
assumed: AlphaFold2's "ARNDCQEGHILKMFPSTWYV" is alphabetical by three-letter
code and therefore equals ATOMWORKS_NAME3S[1:21], and N/CA/C/O occupy atom37
slots (0, 1, 2, 4) for all 20 canonical amino acids. Padding maps to the
atomworks <M> token, which the existing tables already send to tmol restype
-1 with no real atoms.

The packing setup is held in a memoized PoseBuildContext, so the score
function and Dunbrack sampler are built once per device across repeated calls,
not once per structure.

Tests

tmol/tests/io/test_pose_stack_from_backbone_coords.py

Test What it checks
test_build_context_reuses_atomworks_chemistry ordering, block types and param DB are the same objects as the atomworks adapter's
test_default_aa_order_matches_atomworks_protein_tokens the AF2 ↔ atomworks token identity this module relies on
test_backbone_slots_are_uniform_across_restypes N/CA/C/O are at slots (0,1,2,4) for all 20 restypes
test_packing_preserves_the_supplied_backbone every input N/CA/C/O round-trips through packing at atol=0
test_sidechains_are_built_and_no_nans_remain packed pose has no NaN among real atoms, and >4 atoms/residue
test_gradients_reach_the_input_backbone_through_packing autograd reaches coords despite the value-level packing step
test_completion_none_leaves_sidechains_absent "none" leaves NaN where "pack" fills
test_completion_none_is_differentiable the cheap path is fully differentiable
test_repeated_builds_are_stable_and_reuse_setup atom layout and backbone identical across builds; context, score function and sampler reused
test_cpu_and_cuda_agree canonical form identical, pose coords equal within tolerance across devices
test_single_pose_input_is_unsqueezed, test_output_is_on_the_requested_device, test_padding_positions_are_excluded, test_two_chains_are_separated shape/device/padding/chain handling
test_shape_validation, test_out_of_range_res_types_are_rejected, test_aa_order_rejects_non_canonical_and_repeats, test_invalid_completion_policy_is_rejected input contracts

Changes from the previous revision of this PR

  • Rebased onto current master; the PoseStack.split / PDBInfo.split
    cherry-pick is dropped now that Add code to split out a single Pose from a PoseStack #379 is merged (it was the source of the
    conflicts).
  • Dropped _paramdb_for_backbone_coords, canonical_ordering_for_backbone_coords
    and packed_block_types_for_backbone_coords in favour of the atomworks ones,
    and dropped the module's own backbone atom-name table in favour of
    _get_aw_2_tmol_mappings. Public surface is down from five names to two.
  • Side-chain completion is now an explicit sidechain_completion argument
    instead of an unconditional hidden step.
  • The supplied backbone is now restored after packing and stays differentiable.
  • Tests rewritten around backbone preservation, side-chain completeness,
    device equivalence and setup reuse rather than shape and device alone. Those
    tests are what caught the NaN HA, which the previous revision shipped.

Two notes on points raised in review that I did not change, for
transparency: CanonicalForm.coords is Tensor[torch.float32] by contract, so
the float32 cast is the framework's, not this adapter's; and the protein-only
restype subset is the same one pose_stack_from_atomworks and
pose_stack_from_openfold enforce. Happy to follow if either is generalized
repo-wide.

Finally — if you'd rather this live inside _pose_stack_from_atomworks.py as a
sibling of pose_stack_from_atomworks, or would rather relax
canonical_form_from_atomworks to accept its own <M> mask token as padding
(which would reduce this to a thin wrapper), say the word and I'll move it.

@kierandidi

Copy link
Copy Markdown
Collaborator

Current-code audit: the backbone-only use case is useful, but this implementation should not be merged as-is. It duplicates format-specific canonical-ordering/parameter caches now generalized by the Atom37/Biotite path in #465, always materializes float32 output, supports only a hard-coded protein subset, and hides a full score-function/Dunbrack/OptH side-chain packing operation inside what looks like a tensor conversion. The tests mostly assert shape/device rather than preservation of the supplied backbone and deterministic side-chain-completion semantics. The branch is conflicting and has no current CI. I recommend a small follow-up after #465: prepare topology once, make missing-side-chain completion an explicit policy/step, preserve input dtype/autograd where supported, and test CPU/CUDA equivalence plus repeated prepared builds.

@ssiddhantsharma
ssiddhantsharma force-pushed the ssiddhantsharma/pose_stack_from_backbone_coords branch from 85d590e to a0821ce Compare September 4, 2026 07:19
@ssiddhantsharma ssiddhantsharma changed the title Add pose_stack_from_backbone_coords: in-memory PoseStack construction from N/CA/C/O Backbone-only side-chain completion: build a scorable PoseStack from N/CA/C/O Sep 4, 2026
@ssiddhantsharma

ssiddhantsharma commented Sep 4, 2026

Copy link
Copy Markdown
Author

Thanks — reworked rather than patched, and rebased onto master now that #465 has landed.

Duplication. Gone. _paramdb_for_atomworks already builds the identical restype subset, so the four *_for_backbone_coords helpers and the module's own atom-name table are dropped in favour of the atomworks caches and _get_aw_2_tmol_mappings. Public surface is five names down to two. Two facts make the reuse exact, and both are asserted rather than assumed: "ARNDCQEGHILKMFPSTWYV" is alphabetical by three-letter code and so equals ATOMWORKS_NAME3S[1:21], and N/CA/C/O sit at atom37 slots (0, 1, 2, 4) for all 20 restypes. Padding rides the <M> token, which your tables already send to restype -1.

Hidden packing. Now an explicit sidechain_completion: "pack" | "none", documented at the call site as a score-function-driven Dunbrack/OptH job whose output is not differentiable. "none" converts only and stays differentiable. Immutable setup is bound once in a memoized PoseBuildContext.

Two things the new tests turned up:

  1. Backbone-only input leaves a NaN HA on every non-GLY residue. HA is built from CA/N/CB, leaf building runs before packing, and the packer only rebuilds heavy atoms, so it was never placed. "pack" now rebuilds once afterwards (~0.00s against ~0.70s for the pack), and omits return_block_has_missing_atoms on that pass so a real gap raises instead of returning NaN. Not adapter-specific — any backbone-only input reaching pose_stack_from_canonical_form hits it. Happy to split that out as its own issue.

  2. I could not assert seed-reproducible packing, because there isn't any on CPU: the annealer draws from libc rand() (compiled.cpu.cpp lines 29/109/220), which torch.manual_seed doesn't control. Repeated packs differ by up to ~10 Å, and it reaches topology too — His68 comes back HIS on some builds and HIS_D on others, since the tautomer is resolved from the packed side chain. test_default_pose_builds_reuse_packing_setup passes only because a complete 1UBQ skips Dunbrack for the OptH-only path. So the test here asserts setup reuse, atom layout and a bit-identical backbone. Glad to tighten it if you'd rather seed the annealer.

Not changed, for transparency: CanonicalForm.coords is Tensor[torch.float32] by contract, so the float32 cast is the framework's; and the protein-only subset is the same one pose_stack_from_atomworks and pose_stack_from_openfold enforce.

Two questions now that #465 is in:

  • Placement. Happy to move this into _pose_stack_from_atomworks.py, or to relax canonical_form_from_atomworks to accept its own <M> token as padding, which would reduce this to a thin wrapper. Your call.
  • A prepared builder. I didn't add a backbone analogue of prepare_pose_stack_from_atom37: for backbone-only input the cost is dominated by packing, which reruns per structure, so it would only help the "none" path.

@ssiddhantsharma
ssiddhantsharma force-pushed the ssiddhantsharma/pose_stack_from_backbone_coords branch from a0821ce to 475ddae Compare September 4, 2026 12:46
@ssiddhantsharma ssiddhantsharma changed the title Backbone-only side-chain completion: build a scorable PoseStack from N/CA/C/O Backbone-only side-chain completion: scorable PoseStack from N/CA/C/O (follow-up to #465) Sep 4, 2026
Every structure-prediction adapter on master requires side-chain heavy atoms
to already be present: pose_stack_from_canonical_form builds missing leaf
atoms but rejects blocks missing non-leaf atoms. Backbone generators emit only
N/CA/C/O, so today the only way to score one is a PDB round-trip.

Add pose_stack_from_backbone_coords, which takes a bare (L, 4, 3) backbone
plus integer residue types and returns a scorable PoseStack.

Side-chain completion is an explicit sidechain_completion="pack"|"none"
policy rather than a hidden step: "pack" runs a score-function-driven
Dunbrack/OptH job and is documented as such, "none" does the conversion only
and stays differentiable. Under both, the supplied backbone is returned
bit-for-bit and stays on the autograd tape.

Chemistry is reused from the atomworks adapter rather than duplicated: its
parameter database, canonical ordering, packed block types and atom mappings
already cover exactly this restype subset. Packing setup is held in a memoized
PoseBuildContext so the score function and Dunbrack sampler are built once per
device.

A leaf atom whose ideal geometry reaches into the side chain cannot be placed
from a bare backbone -- HA is built from CA/N/CB -- and leaf building runs
before packing, so those atoms were left NaN on every non-GLY residue. Rebuild
the pose once after packing, when the heavy atoms exist.
@ssiddhantsharma
ssiddhantsharma force-pushed the ssiddhantsharma/pose_stack_from_backbone_coords branch from 475ddae to 513609e Compare September 4, 2026 12:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants